Cranelift: extend alias analysis to eliminate dead stores#13806
Conversation
This commit adds an "observed?" bit to every alias region in the alias analysis's `LastStores` data. Loads from a region set the bit, as do instructions that act as memory fences, like calls. When we see a store to a region that hasn't been observed, and the store is to the same memory location as the last store to that region, then the last store is dead and can be removed. This integrates with alias analysis's existing single forwards pass that is fused with the egraph pass; it doesn't require additional passes or iterations. However, because it is a single pass and not a fixed point, updates do not cascade, and removing a dead store does not then recompute `LastStores` and reveal that (for example) the dead store's killer is an idempotent store which could also be removed. See the new `check-unset-reset-flag.clif` filetest, which roughly reflects what component-model fused adapters do with the `MAY_LEAVE` flag, for an example of this behavior. Fixes bytecodealliance#4167
|
This is a speed up for our compile-time builtins benchmarks, as mentioned here: On a PCA-based subset of Sightglass:
Sightglass resultsI can look into speeding things up a bit more, but we should probably discuss this at the next Cranelift meeting. |
|
I'm going to move this to being reviewed by @cfallin who I know is on PTO right now but I think will have sufficiently more context about this that he'll do a much better job than I |
|
(happy to do so; logistical note that I'm out on PTO this week and next, then traveling-for-work the following week, so I can't guarantee I'll get to this before Mon Jul 20) |
cfallin
left a comment
There was a problem hiding this comment.
This looks correct to me -- thanks for the really careful work here!
A few points below but nothing fundamental.
|
|
||
| /// Determine whether this opcode behaves as a memory fence, i.e., | ||
| /// prohibits any moving of memory accesses across it. | ||
| fn has_memory_fence_semantics(op: Opcode) -> bool { |
There was a problem hiding this comment.
Any reason that this moved out of inst_predicates? It seems more natural to me to put it there; it's better not to have a distributed set of opcode sets throughout the code, IMHO, rather than reading through that module when we add a new opcode. (Lack of fallthrough to enforce consideration would be best but that's not practical on predicates like this imho)
| SkeletonInstSimplification::RemoveDeadStore { dead, killer } => { | ||
| assert!(!matches!(cursor.position(), CursorPosition::At(inst) if inst == dead)); | ||
| // Copy the trap code (if any) from the dead store to its | ||
| // killer. |
There was a problem hiding this comment.
minor subjective point but although compiler jargon can be pretty... forceful... at times, it might be a little nicer to read to call the "killer" something like shadower or overwriter? :-)
| .unwrap(); | ||
| let flags = flags.with_trap_code(Some(code)); | ||
| let flags = cursor.func.dfg.mem_flags.insert(flags).unwrap(); | ||
| *cursor.func.dfg.insts[killer].memflags_mut().unwrap() = flags; |
There was a problem hiding this comment.
What happens if the shadowing ("killing") store had a conflicting trap code? Do we avoid that by construction in alias analysis by not deciding to merge (we don't actually shadow if the trap-code is different)? Should we assert the trap-code is the same here?
(One way to get that behavior in alias analysis if we don't already is to make the trap-code a field in the last-store tuple in the carried flow-sensitive state)
And actually, if we do match in that way, then I believe we don't need memflags_mut anymore, nor this logic. I believe "trap code must match" should cover all of our desired dead-store cases?
| && inst != last_store | ||
| // The last store isn't dead if this | ||
| // instruction is a fetch-add or something | ||
| // like that. |
There was a problem hiding this comment.
To clarify the comment (because this sounds a little bit like a handwave "too complex" case or something): when we have both can_store() and can_load(), the expected semantics are that the load happens before the store, so the last store is in fact observed (just-in-time, right before processing the store effect). That's why we skip in this case, just as we skip when observed.
|
Regarding the compilation hit that this PR implies: I have a handful of optimizations that I will start making PRs for that recover that compile time in other areas. |
|
One point to track as a followup further improvement, possibly: if we have a mode (enforced by Wasmtime API) that ensures that the |
This commit adds an "observed?" bit to every alias region in the alias analysis's
LastStoresdata. Loads from a region set the bit, as do instructions that act as memory fences, like calls. When we see a store to a region that hasn't been observed, and the store is to the same memory location as the last store to that region, then the last store is dead and can be removed. This integrates with alias analysis's existing single forwards pass that is fused with the egraph pass; it doesn't require additional passes or iterations. However, because it is a single pass and not a fixed point, updates do not cascade, and removing a dead store does not then recomputeLastStoresand reveal that (for example) the dead store's killer is an idempotent store which could also be removed. See the newcheck-unset-reset-flag.cliffiletest, which roughly reflects what component-model fused adapters do with theMAY_LEAVEflag, for an example of this behavior.Fixes #4167